In [2]:
# Simplest way to create a Python object 
class Polynomial1:
    pass

p1 = Polynomial1()
p2 = Polynomial1()

p1.coeffs = 1,2,3
p2.coeffs = 3,4,5
# The problem with this code is that it contains repeated code

In [29]:
class Polynomial2:
    def __init__(self, *coeffs):
        self.coeffs = coeffs

p1 = Polynomial2(1,2,3)
p2 = Polynomial2(3,4,5)
# Now we accomplished the same task of object creation by using init function

In [30]:
print(p1)
print(p2)
# Now this looks so ugly


<__main__.Polynomial2 object at 0x7f527c2bb128>
<__main__.Polynomial2 object at 0x7f527c2bba20>

In [31]:
# I am missing __repr__ in my class
class Polynomial3:
    def __init__(self, *coeffs):
        self.coeffs = coeffs
    def __repr__(self):
        return 'Polynomial(*{!r})'.format(self.coeffs)

p1 = Polynomial3(1,2,3)
p2 = Polynomial3(3,4,5)

print(p1)
print(p2)

# The function implemented in __repr__ by uses !r whcih is used to place the contenst of format


Polynomial(*(1, 2, 3))
Polynomial(*(3, 4, 5))

In [34]:
# Now I want to add them
class Polynomial4:
    def __init__(self, *coeffs):
        self.coeffs = coeffs
    def __repr__(self):
        return 'Polynomial(*{!r})'.format(self.coeffs)
    def __add__(self, other):
        return Polynomial4(*(x+y for x, y in zip(self.coeffs, other.coeffs)))
    def __len__(self):
        return len(self.coeffs)
p1 = Polynomial4(1,2,3)
p2 = Polynomial4(3,4,5)

print(p1+p2)
print(len(p1))


Polynomial(*(4, 6, 8))
3

In [35]:
# The double underscore functions that I use here are called data model methods
# x + y  -> __add__
# init x  -> __init__
# represent x ->  __repr__
# We can see that there is strong correlation ampong the function implemented by data model functions and the name of the function

In [36]:
# To understand the object oriented view of python you must know these three
# 1) The protocols aka data model
# 2) Built in inheritance protocol
# 3) How oop in python works

Metaclasses


In [50]:
# For this example suppose you have two teams develoer and core infrastructure team that writes library software. The library provides classes that are than made 
# subclass in user

In [ ]:
# Case 1 where you work at user.py

In [46]:
# Library.py
class Base:
    def foo(self):
        return 'foo'

In [49]:
# user.py

assert hasattr(Base, 'foo'), "You broke it fool!"    # It checks that the base library contains the needed methods
class Derived(Base):
    def bar(self):
        return self.foo
        # This statement can break when their is no foo method in the base class. To overcome this you can use assert

In [51]:
# Case 2 where you work at library.py
# Here you assume that you will implement bar function in the future and you want to deal with the user.py for not to use that method

In [1]:
# library.py

# We can use try except but that will only work at run time
class Base:
    def foo(self):
        return self.bar

In [2]:
# user.py

class Derived(Base):
    def bar(self):
        return 'bar'

In [3]:
# To view actual runtime instructions 
from dis import dis

def _():
    class Some:
        pass

dis(_)


  5           0 LOAD_BUILD_CLASS
              2 LOAD_CONST               1 (<code object Some at 0x7f5bf0f5d300, file "<ipython-input-3-fd1254fc8d14>", line 5>)
              4 LOAD_CONST               2 ('Some')
              6 MAKE_FUNCTION            0
              8 LOAD_CONST               2 ('Some')
             10 CALL_FUNCTION            2
             12 STORE_FAST               0 (Some)
             14 LOAD_CONST               0 (None)
             16 RETURN_VALUE

In [4]:
# What it does is it shows the exact instructions that python follows at runtime
# Now we have a underscore function that allows you to view your class while you are building it

In [6]:
old_bc = __build_class__
def my_bc(*a, **kw):
    print('my build class -> ', a, kw)
    return old_bc(*a, **kw)
import builtins
builtins.__build_class__ = my_bc

print(my_bc)


<function my_bc at 0x7f0504ea3f28>

In [6]:
# Now I can use the obove approach to check classes for functions
old_bc = __build_class__
def my_bc(fun, name, base=None, **kw):
    if base == Base:
        print('Check if bar function is defined')
    if base is not None:
        return old_bc(fun, name, bases, **kw)
    return old_bc(fun, name, **kw)

import builtins
builtins.__build_class__ = my_bc

In [7]:
# The above method works when you run the code from a terminal and in practice it is not implemented in this way.
# To solve our case 2 problem we use metaclasses.

In [1]:
# library.py

class BaseType(type):
    def __new__(cls, name, bases, body):
        print('BaseMeta.__new__', cls, name, bases, body)
        return super().__new__(cls, name, bases, body)
    
class Base(metaclass = BaseType):
    def foo(self):
        return self.bar()


BaseMeta.__new__ <class '__main__.BaseType'> Base () {'__module__': '__main__', '__qualname__': 'Base', 'foo': <function Base.foo at 0x7f245ca1d620>}

In [ ]:
# We can see the body of the class as a dict.
# Now to get the desired behaviour e can use assert

In [4]:
class BaseType(type):
    def __new__(cls, name, bases, body):
        if not 'bar' in body:
            raise TypeError ("Bad user class")
        return super().__new__(cls, name, bases, body)
    
class Base(metaclass = BaseType):
    def foo(self):
        return self.bar()


---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-4-da631fb1e396> in <module>()
      5         return super().__new__(cls, name, bases, body)
      6 
----> 7 class Base(metaclass = BaseType):
      8     def foo(self):
      9         return self.bar()

<ipython-input-4-da631fb1e396> in __new__(cls, name, bases, body)
      2     def __new__(cls, name, bases, body):
      3         if not 'bar' in body:
----> 4             raise TypeError ("Bad user class")
      5         return super().__new__(cls, name, bases, body)
      6 

TypeError: Bad user class

In [6]:
# We get the error message as we have not yet made the bar method in Base

Decorators


In [9]:
# Suppose I want to time the given function \

def add(x, y=10):
    return x+y

print('add(10) ', add(10))
print('add(20,30) ', add(20,30))
print('add("a", "b") ', add("a", "b"))


add(10)  20
add(20,30)  50
add("a", "b")  ab

In [12]:
from time import time
def add(x, y=10):
    return x+y
before = time()
print('add(10) ', add(10))
after = time()
print('Time taken ', after - before)
before = time()
print('add(20,30) ', add(20,30))
after = time()
print('Time taken ', after - before)
before = time()
print('add("a", "b") ', add("a", "b"))
after = time()
print('Time taken ', after - before)

# This approach uis bad as code repetition


add(10)  20
Time taken  7.343292236328125e-05
add(20,30)  50
Time taken  4.458427429199219e-05
add("a", "b")  ab
Time taken  4.38690185546875e-05

In [15]:
def add(x, y=10):
    before = time()
    rv = x + y
    after = time()
    print('Time taken ', after - before)
    return rv

print('add(10) ', add(10))
print('add(20,30) ', add(20,30))
print('add("a", "b") ', add("a", "b"))

# Now if I have another function like
def sub(x, y=10):
    return x-y
# Then you would have to enter all the code again and it will become a mess for a large number of functions


Time taken  4.76837158203125e-07
add(10)  20
Time taken  2.384185791015625e-07
add(20,30)  50
Time taken  4.76837158203125e-07
add("a", "b")  ab

In [19]:
def timer(func, x, y=10):
    before = time()
    rv = func(x,y)
    after = time()
    print('Time taken ', after-before)
    return rv

def add(x, y=10):
    return x+y
def sub(x, y=10):
    return x-y

print('add(10) ', timer(add, 10))
print('add(20,30) ', timer(add, 20,30))
print('add("a", "b") ', timer(add, "a", "b"))

# This approach is better than the last one


Time taken  4.76837158203125e-07
add(10)  20
Time taken  4.76837158203125e-07
add(20,30)  50
Time taken  4.76837158203125e-07
add("a", "b")  ab

In [26]:
def timer(func):
    def f(x,y=10):
        before = time()
        rv = func(x,y)
        after = time()
        print('Time taken ', after-before)
        return rv
    return f

def add(x, y=10):
    return x+y
add = timer(add)

def sub(x, y=10):
    return x-y
sub = timer(sub)

print('add(10) ', add(10))
print('add(20,30) ', add(20,30))
print('add("a", "b") ', add("a", "b"))

# Here I wrapped a function around another function


Time taken  4.76837158203125e-07
add(10)  20
Time taken  4.76837158203125e-07
add(20,30)  50
Time taken  4.76837158203125e-07
add("a", "b")  ab

In [27]:
# The above functionality of wrapping a function around another function is provided by decorator

def timer(func):
    def f(x,y=10):
        before = time()
        rv = func(x,y)
        after = time()
        print('Time taken ', after-before)
        return rv
    return f

@timer
def add(x, y=10):
    return x+y

@timer
def sub(x, y=10):
    return x-y

print('add(10) ', add(10))
print('add(20,30) ', add(20,30))
print('add("a", "b") ', add("a", "b"))


Time taken  4.76837158203125e-07
add(10)  20
Time taken  4.76837158203125e-07
add(20,30)  50
Time taken  2.384185791015625e-07
add("a", "b")  ab

In [29]:
# If you want to run a function n times say decorators
def ntimes(n):
    def inner(f):
        def wrapper(*args, **kwargs):
            for _ in range(n):
                print('running {.__name__}'.format(f))
                rv = f(*args, **kwargs)
            return rv
        return wrapper
    return inner

@ntimes(2)
def add(x, y=10):
    return x+y

@ntimes(5)
def sub(x, y=10):
    return x-y

print('add(10)', add(10))
print('add(10,20)', add(10,20))
print('sub(10)', sub(10))
print('sub(20,10)', sub(20,10))


running add
running add
add(10) 20
running add
running add
add(10,20) 30
running sub
running sub
running sub
running sub
running sub
sub(10) 0
running sub
running sub
running sub
running sub
running sub
sub(20,10) 10

Generators


In [32]:
# Every x() function is implemented by __call__
def add1(x,y):
    return x+y

class Adder:
    def __call__(self, x, y):
        return x+y

add2 = Adder()

print(add1(10,20))
print(add2(10,20))

# The only difference between them is that add1 is easy to write than add2. Internally add1 is implemented in a very close manner as add2


30
30

In [34]:
# Suppose you are doing database loading with python and doing some operation which I show for simplicity as waiting got .5 sec.
from time import sleep

def compute():
    rv = []
    for i in range(10):
        sleep(.5)
        rv.append(i)
    return rv

compute()
# The problem with this function is that it returns all the values together. Suppose that the wait time is very large than you will
# have to wait a long time. Or you could get one value at a time and process it one by one.


Out[34]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

In [37]:
class Compute:
    def __iter__(self):
        self.last = 0
        return self
    def __next__(self):
        rv = self.last
        self.last += 1
        if(self.last > 10):
            raise StopIteration()
        sleep(.5)
        return rv

for val in Compute():
    print(val)
    
# This method also uses less memory as earlier we had to store the entire list and here it is not the case


0
1
2
3
4
5
6
7
8
9

In [40]:
# Generator syntax allows you to write the __iter__ and __call__ in a nice and clean manner
def compute():
    for i in range(10):
        sleep(.5)
        yield i

for val in compute():
    print(val)


0
1
2
3
4
5
6
7
8
9

In [42]:
class API:
    def run_this_first(self):
        first()
    def run_this_second(self):
        second()
    def run_this_last(self):
        last()

# Now to make sure that they run in the same order you can use generator. Creating a new function with all the mehtods written in the
# desired order is not an option like 
# def doit():
#   first()
#   second()
#   last()
# because we want to get user input adter each function completes it's execution

def api():
    first()
    yield
    second()
    yield
    last()

# Here after running first() it will wait for user response and then go to second()

Context Managers


In [43]:
# Similar to resource allocation is initialization. Suppose you open a file and then after you operate on it, you would have to close in
# order to free up the memory or flush it out.

In [ ]: